Skip to content

Fix dev-server and test-runner bugs; move formatter options; Rollup-style externals - #49

Merged
dannote merged 13 commits into
masterfrom
investigate-agent-issues
Oct 3, 2026
Merged

dannote merged 13 commits into
masterfrom
investigate-agent-issues

Conversation

@dannote

@dannote dannote commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

Fixes a batch of issues found while running Phoenix apps (including the PhoenixVapor demo) on Volt 0.19.4, plus two breaking config changes. Every change has a line in the Unreleased section of the changelog.

Breaking changes

  • Formatter options move to .formatter.exs. They are read from the :volt key, where mix format plugins keep their options. config :volt, :format now only holds the build output format, so both can be set. A keyword list under the old key raises an ArgumentError that prints the options to move. The formatting-only :root, :sources and :ignore overrides move to the same key. The installer, guides, examples and Volt's own config follow.

    # .formatter.exs
    [
      plugins: [Volt.Formatter],
      volt: [semi: false, single_quote: true]
    ]
  • Externals stay as imports in :esm and :cjs output, as in Rollup, Rolldown, esbuild and Bun. Only IIFE output reads them from globals, and the global names in external: %{"phoenix" => "Phoenix"} apply to IIFE only.

    Format Output for import { Socket } from "phoenix"
    :iife const { Socket } = Phoenix; (unchanged)
    :esm import { Socket } from "phoenix";
    :cjs require("phoenix")

Dev server and watcher

  • Stop the page reloading in a loop when a watched file is rewritten with identical content (LiveView colocated hooks on every code reload). The watcher compares contents before rebuilding.
  • Resolve relative watch_ignored patterns from the project directory as well as each watch root.
  • Convert local CommonJS/UMD files such as vendor/topbar.js to ES modules in development. .cjs and .cts files, which the dev server did not serve, are converted the same way.
  • Load one instance of each pre-bundled dependency. Pre-bundles imported siblings and shared chunks without the ?v= hash, so Vue was loaded twice and component libraries failed to render.
  • Explain why a development session is unavailable instead of answering a bare 503, for example when a Mix.Tasks.Volt.Dev watcher runs next to plug Volt.DevServer.
  • Log Pre-bundled N vendor package(s) only when packages are bundled, not on every request.
  • Stop watchers before the Tailwind processes they depend on. The supervisor's child order stopped the Tailwind registry first, so every Tailwind-enabled watcher crashed on shutdown.

Build

  • Skip tsconfig.json path mappings that only point at declaration files.
  • Read external globals once in code-split IIFE output; chunks also declared them at the top level of the script.

Test runner and types

  • Collect every test generated by test.each and describe.each. Lines were zipped onto tests by position, which dropped tests without a detected line. Line lookup now counts newlines once instead of once per test (about 214 ms to 81 ms on a 1,200-test file).
  • Declare import.meta.env in the client types.

Verification

  • mix test: 752 passed, 5 skipped, 39 excluded by tag. mix format --check-formatted and mix volt.js.check pass.
  • The duplicate-Vue and CommonJS fixes were confirmed against the PhoenixVapor demo in dev (reka-ui page, 10 Playwright interaction steps).
  • The new env.d.ts type-checks with tsc alongside the other client declarations.
  • The shutdown crash was reproduced by starting a Tailwind watcher session and calling Application.stop(:volt); it stops cleanly after the reorder.
  • Not verified in a running app: the reload-loop fix (watcher-level tests only).

Not included

  • Version bump and dated changelog section.
  • The dev server still rescans sources for bare imports on every request under Phoenix's per-request plug init; only the log was fixed.
  • import.meta.glob is still not declared in the client types.

dannote added 13 commits October 3, 2026 12:57
Dev server and watcher:
- Compare file contents before rebuilding, so a file rewritten with
  identical content (LiveView colocated hooks on every code reload) no
  longer reloads the page in a loop.
- Resolve relative watch_ignored patterns from the project directory as
  well as each watch root.
- Convert local CommonJS/UMD files such as vendor/topbar.js to ES
  modules in development.
- Append the browser hash to relative imports inside served pre-bundles,
  so a package reached from application code and from another pre-bundle
  is a single module instance. Vue was loaded twice before.

Build:
- Skip tsconfig path mappings that only point at declaration files.
- Keep external modules as imports in :esm and :cjs output, as Rollup
  does. Only IIFE output reads them from globals. (Breaking.)

Formatter:
- Read formatter options from the :volt key of .formatter.exs instead of
  config :volt, :format, which now only holds the build output format. A
  keyword list there raises with the options to move. The installer,
  guides, examples and Volt's own config follow. (Breaking.)
A second watcher with different options, such as a Mix.Tasks.Volt.Dev
entry in the endpoint's :watchers next to plug Volt.DevServer, made
every request answer a bare 503. The response and an error log now name
the conflict and how to resolve it.
Phoenix initializes plugs on every request in development, so the dev
server logged "Pre-bundled N vendor package(s)" per request even when
the cache was fresh. The log moves to where bundling happens.
Chunks already read their external globals inside the IIFE. A second
copy was prepended at the top level of each script, where two chunks
importing the same name would clash.
During application shutdown a watcher can terminate after
Volt.Tailwind.Registry has stopped, and releasing its context raised
"unknown registry". There is nothing left to release then.
Source lines were matched to collected tests by position with Enum.zip,
which truncates to the shorter list. The line scan only recognized plain
test() and it() calls, so a five-case table followed by two tests was
collected as two tests.

Each case now maps to the line of its table, and describe.each repeats
its inner tests per case. When the source shows a different number of
tests than the runtime collected, such as tests registered in a loop,
all tests are kept without a line.
The client types only declared import.meta.hot, so TypeScript projects
had to declare ImportMeta.env themselves. The installer adds env.d.ts to
configurations that list declaration files explicitly.
They were not in the dev server's compilable extensions, so the private
extension list in Volt.JS.CommonJS could never match .cjs. Both are
CommonJS by extension and go through the same conversion as CommonJS
.js files; only .js is inspected. The list comes from
Volt.JS.Extensions.
The watcher supervisor started before the Tailwind registry and worker
supervisor, so on shutdown the Tailwind processes stopped first and
every watcher with Tailwind enabled crashed in terminate/2 with
"unknown registry: Volt.Tailwind.Registry".

Start the Tailwind processes first. This replaces the Process.whereis
guard in Volt.Tailwind.Supervisor.release/1, which hid the ordering
problem.
Volt.Test.Lines goes back to OXC.postwalk/3 instead of a hand-rolled
traversal. Post-order reaches the tests in a describe.each body before
the describe.each call, which folds them into one repeated group.

Line numbers were found by counting newlines from the start of the
source for every test. On a 110 KB file with 1,200 tests that took
about 140 ms of 214 ms; a single pass over the newline offsets brings
the whole function to about 81 ms, most of it OXC.parse.
mix lint failed on Elixir 1.20 / OTP 29, the job that runs it:

- Credo: two single-item list appends.
- Architecture policy: Volt.JS.CommonJS had no layer, and Volt.Config
  (model) called Volt.JS.Format (logic), closing a layer cycle. The
  check for formatter options left under config :volt, :format moves to
  Volt.Config, which Volt.JS.Format calls.
- Smells: a case returning booleans in the dev server, and a member
  expression shape repeated across the callee matchers in
  Volt.Test.Lines, now one function.
Volt's .formatter.exs held the formatter options but did not list
Volt.Formatter, so mix format never exercised the plugin on Volt's own
sources. Add the plugin and the priv/ts sources that mix volt.js.format
already covers.
mix format loads a project's own formatter plugin from the existing
build and compiles only when the module cannot be loaded. CI restores a
cached _build, so it formatted Volt's TypeScript with the previous
Volt.Formatter, which did not read options from .formatter.exs, and
reported every file as unformatted.
@dannote
dannote merged commit 098c19e into master Oct 3, 2026
2 checks passed
@dannote dannote mentioned this pull request Oct 3, 2026
@dannote
dannote deleted the investigate-agent-issues branch October 3, 2026 13:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant